"""Privsep shell-like operations.

The shellops command package provides the following commands:
FILECOPY(src, dst):
	Request the other side copy the file named by `src' to the file named
	by `dst'.
	raises: CopyFailed, CopyRefused
	returns:
		N/A
	Policy:
		Default deny with subdirectory whitelisting. The policy can be
		fed with SourcePath and DestinationPath instances to whitelist.

		NB: CopyPath instances whitelist the entire directory tree rooted
		at the given path.
		
UNLINK(path):
	Request the other side unlink the file named by `path'.
	raises: UnlinkFailed, UnlinkRefused
	returns:
		N/A
	Policy:
		Default deny. The policy can be fed with strings naming directory
		trees to allow unlinking within.
"""

from unixutils.privsep.command import Message, Command, CommandPackage, \
	CommandPolicy, CMDException, find_commands, argtypes, obj

import shutil
import os
import os.path

# pylint: disable-msg=R0901,R0903,W0221

def _canonize_path(path):
	"""Expand the given path to a canonical representation, without
	'.', '~', or the presence of symlink nonsense."""

	normalizers = (os.path.abspath, os.path.normpath, os.path.realpath)

	for normer in normalizers:
		path = normer(path)
	return path

def _lencmp(lhs, rhs):
	"""Compare the lengths of the lhs and rhs strings, suitable for
	sorting longest string first."""

	return -cmp(len(lhs), len(rhs))

def _prefix_match(path, directory):
	"""Check to see if `path' is part of the directory tree rooted
	at `directory'."""

	return os.path.commonprefix((directory, path)) == directory

def _touch(filename):
	"""Update file access and modification times. Create the named
	file if it doesn't exist."""

	os.close(os.open(filename, os.O_RDONLY | os.O_CREAT))

class CannotHappen(Exception):
	"""OMGWTFBBQ."""
	pass

COPY_SUCCESS = Message("FILECOPY_SUCCESS")

class CopyFailed(OSError, CMDException):
	"""The requested file copy failed for some reason."""
	pass

class CopyRefused(OSError, CMDException):
	"""The requested file copy was refused by policy."""
	pass

class CopyPath(object):
	"""A class that represents a path involved in a copy operation
	and whether that path is being copied from or to."""

	def __init__(self, path, direction):
		"""Create a CopyPath object from the given path and direction."""
		self.path = path
		self.direction = direction

	def __len__(self):
		"""Obtain the length of the path."""
		return len(self.path)

class SourcePath(CopyPath):
	"""Representation of paths to be copied from."""
	def __init__(self, path):
		"""Create from the given path."""
		CopyPath.__init__(self, path, 'src')

class DestinationPath(CopyPath):
	"""Representation of paths to be copied to."""
	def __init__(self, path):
		"""Create from the given path."""
		CopyPath.__init__(self, path, 'dst')

class FileCopyCommand(Command):
	"""FILECOPY implementation."""

	class Policy(CommandPolicy):
		"""Policy for FileCopyCommand."""
		def __init__(self, owner):
			"""Init."""
			CommandPolicy.__init__(self, owner)
			self.rules = []

		@argtypes(obj, obj)
		def policy_check(self, copypath):
			"""Check to see the given CopyPath is within the whitelist."""
			copypath.path = _canonize_path(copypath.path)

			prefixes = [x.path for x in sorted(self.rules, _lencmp)
				if x.direction == copypath.direction]

			for prefix in prefixes:
				if _prefix_match(copypath.path, prefix):
					return True
			else:
				return False

		def add_rule(self, rule):
			"""Add a CopyPath to the whitelist."""
			self.rules.append(rule)

		def add_rules(self, *rules):
			"""Add several CopyPaths to the whitelist."""
			self.rules.extend(rules)

		def set_rules(self, *rules):
			"""Set the whitelist to be the provided CopyPaths."""
			self.rules = rules

	def __init__(self, name="FILECOPY"):
		"""Create the FileCopyCommand."""
		Command.__init__(self, name, FileCopyCommand.Policy)

	@argtypes(obj, obj, str, str)
	def invoke(self, ctx, src, dst):
		"""FILECOPY(src, dst) on the caller's side."""
		ctx.channel.push(self, src, dst)
		(_, retval) =  ctx.channel.service_call(COPY_SUCCESS)

		return retval

	@argtypes(obj, str, str)
	def _policy_check(self, src, dst):
		"""Verify the source and destination paths are acceptable."""
		return (self.policy_check(SourcePath(src))
			and self.policy_check(DestinationPath(dst)))

	def handle_pre(self, ctx, src, dst):
		"""Pre-copy handler-side hook for subclasses."""
		pass

	# pylint: disable-msg=R0913
	def handle_post(self, ctx, src, dst, success, exc=None):
		"""Post-copy handler-side hook for subclasses."""
		pass
	# pylint: enable-msg=R0913

	@argtypes(obj, obj, str, str)
	def handle(self, ctx, src, dst):
		"""FILECOPY(src, dst) on the callee's side.

		Check to verify (src, dst) with existing policy and then
		copy from src to dst if acceptable.
		"""
		handle_post = lambda x, y: self.handle_post(ctx, src, dst, x, y)

		if not self._policy_check(src, dst):
			ctx.channel.call(CopyRefused)
			return

		self.handle_pre(ctx, src, dst)

		try:
			shutil.copy2(src, dst)
		except (IOError, OSError), exc:
			ctx.channel.call(CopyFailed)
			handle_post(False, exc)
		else:
			ctx.channel.push(COPY_SUCCESS)
			handle_post(True, None)

	def test_parent(self, ctx):
		"""Test the callee's side of FILECOPY."""
		self.policy.set_rules(SourcePath('/tmp'),
			SourcePath('/private/tmp'),
			DestinationPath('/tmp/__doesnotexist__'),
			DestinationPath('/tmp'),
			DestinationPath('/private/tmp'),
			DestinationPath('/private/tmp/__doesnotexist__'),
			DestinationPath('/root'))

		for _ in range(0, 3):
			ctx.channel.service_call(self)

	def test_child(self, ctx):
		"""Test the caller's side of FILECOPY."""
		import unixutils.testing.asserts as asserts
		from hashlib import sha1 as chash

		def digest(filename):
			"""SHA-1 the contents of `filename'."""
			file_contents = ''.join(open(filename, 'r').readlines())
			return chash(file_contents).hexdigest()

		src = '/tmp/xauthcrap'
		dst1 = '/tmp/xauthgarbage'
		dst3 = '/tmp/__doesnotexist__/xauthcrap'
		dst4 = '/var/xauthcrap'

		_touch(src)
		ctx.channel.call(self, src, dst1)
		asserts.assert_(digest(src) == digest(dst1))

		asserts.assert_raises(lambda: ctx.channel.call(self, src, dst3),
			CopyFailed)

		asserts.assert_raises(lambda: ctx.channel.call(self, src, dst4),
			CopyRefused)

UNLINK_SUCCESS = Message("UNLINK_SUCCESS")

class UnlinkRefused(OSError, CMDException):
	"""Unlink was refused by policy."""
	pass

class UnlinkFailed(OSError, CMDException):
	"""Unlink failed for some reason."""
	pass

class UnlinkCommand(Command):
	"""UNLINK implementation."""
	class Policy(CommandPolicy):
		"""Policy for UnlinkCommand."""
		def __init__(self, owner):
			"""Init."""
			CommandPolicy.__init__(self, owner)
			self.rules = []

		@argtypes(obj, str)
		def policy_check(self, path):
			"""Check to see that `path' is within the list of directories
			the caller is allowed to delete things from."""
			path = _canonize_path(path)

			for prefix in sorted(self.rules, _lencmp):
				if _prefix_match(path, prefix):
					return True
			else:
				return False

		@argtypes(obj, str)
		def add_rule(self, rule):
			"""Add directory tree to allow unlinking within."""
			self.rules.append(rule)

		def add_rules(self, *rules):
			"""Add directory trees which can be unlinked within."""
			self.rules.extend(rules)

		def set_rules(self, *rules):
			"""Set the directory trees which can be unlinked within."""
			self.rules = rules

	def __init__(self):
		"""Create the UNLINK command."""
		Command.__init__(self, "UNLINK", UnlinkCommand.Policy)

	@argtypes(obj, obj, str)
	def invoke(self, ctx, path):
		"""Caller's side of UNLINK(path). Submits an UNLINK request
		to the other side."""
		ctx.channel.push(self, path)
		_ = ctx.channel.service_call(UNLINK_SUCCESS)

	@argtypes(obj, obj, str)
	def handle(self, ctx, path):
		"""Callee's side of UNLINK(path). Unlink the named file
		if it is allowed by policy."""

		if not self.policy_check(path):
			ctx.channel.call(UnlinkRefused)
			return

		try:
			os.unlink(path)
		except (IOError, OSError):
			ctx.channel.call(UnlinkFailed)
		else:
			ctx.channel.call(UNLINK_SUCCESS)

	def test_parent(self, ctx):
		"""Test the callee's side of UNLINK."""
		self.policy.set_rules('/tmp', '/tmp/__noexist__',
			'/private/tmp', '/private/tmp/__noexist__')

		for _ in range(0, 3):
			ctx.channel.service_call(self)

	def test_child(self, ctx):
		"""Test the caller's side of UNLINK."""
		import unixutils.testing.asserts as asserts

		filename1 = '/tmp/._blarblarnooblePYNIX'
		filename2 = '/tmp/__noexist__/.blarblargnoobPYNIX'
		filename3 = '/root/__noexist__'

		_touch(filename1)
		ctx.channel.call(self, filename1)

		asserts.assert_raises(lambda: ctx.channel.call(self, filename2),
			UnlinkFailed)
		asserts.assert_raises(lambda: ctx.channel.call(self, filename3),
			UnlinkRefused)

# pylint: disable-msg=C0103,W0142
shellops = CommandPackage(*find_commands(locals()))
